fix(sse): stop connection slots leaking when a client disconnects early - #532
Merged
Conversation
claim_sse_slot() runs in the handler and release_sse_slot() lived in the stream's finally. An async generator that is never started never runs its finally, so a client that disconnected before the response body began held a slot for the life of the process: StreamingResponse raises inside stream_response on its first send(), before __anext__ is ever called, and the generator body never executes. Two hundred of those and every job-progress stream and the queue stream answer 503 with nothing actually connected, until a restart. It does not need malice either -- a flaky network or a rapidly reloaded page leaks slots the same way, just slower. Claiming cannot simply move inside the generator: by the time it runs the response headers have gone out and there is no status code left, so hitting the cap could no longer answer 503. SseSlot keeps the claim in the handler and makes release idempotent, with __del__ as the backstop for the never-started case -- collecting the generator collects the closure holding the slot. _held is assigned before the claim because __del__ runs on a half-built object too: a refused claim would otherwise raise AttributeError out of __del__ rather than releasing nothing. The test for that caught it. queue.py takes the same guard, so both streams that share the budget also share the fix. Verified: removing the __del__ backstop fails the never-started test. Refs #513
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #513. Independent of the other open PRs; branches off
0.16.1.The leak
claim_sse_slot()runs in the handler;release_sse_slot()lived in the stream'sfinally.An async generator that is never started never runs its
finally.StreamingResponse.__call__awaitsstream_response, whose first statement isawait send({"type": "http.response.start", ...}). If the client is already gone that raisesClientDisconnectbeforeasync forever calls__anext__-- so the generator body never executes and the slot is held for the life of the process.Connect to
/api/jobs/<id>/events, RST immediately, repeat 200 times: every progress stream and the queue stream answer 503 with zero live connections, until a restart._sse_activehas no ceiling reset and no reconciliation.This does not need malice. A flaky network, or a page reloaded rapidly, leaks slots the same way.
Why not just claim inside the generator
That was the obvious fix and it does not work. By the time the generator runs, the response headers have gone out -- there is no status code left to send, so hitting the cap could no longer answer 503. The claim has to stay in the handler.
The fix
SseSlotkeeps the claim in the handler and makes release idempotent, with__del__as the backstop for the never-started case: collecting the generator collects the closure holding the slot. The stream still releases on its normal path, and the two cannot double-count.queue.pytakes the same guard, so both streams sharing the budget share the fix.A bug the tests caught in the fix itself
_heldis assigned before the claim:__del__runs on a half-built object too. Without that first line, a refused claim raisedAttributeErrorout of__del__instead of releasing nothing --test_a_refused_claim_holds_nothingfailed on the first run and found it.Verification
New
tests/test_sse_slot_budget.py, 5 tests. Confirmed not vacuous -- removing the__del__backstop failstest_a_slot_dropped_without_release_is_reclaimed.The 2 failures are the pre-existing ogg pair, which fail identically on
0.16.1.One caveat for reviewers
_sse_activeis a plain module-level int with no lock. That predates this change, and everything normally touches it from the event loop -- but__del__can run on whichever thread triggers collection, so the backstop is a slightly wider surface than the existing code had. Given the value only ever moves by one and the alternative was a permanent leak, that felt like the right trade; worth a second opinion. A lock around both helpers would close it if you want belt and braces.